Skip to content

S03-01 核心类-Object

[TOC]

概述

什么是 Object

Object:Java 所有类的“根父类”:所有类(自定义类、内置类、数组)都直接或间接继承它。

  • 自动继承:任何没有显式声明父类的类,默认继承 java.lang.Object(比如 class Person {} 等价于 class Person extends Object {});
  • 间接继承:显式继承的类会通过继承链间接继承 Object(如 class Dog extends AnimalAnimal 继承 Object → Dog 间接继承 Object);
  • 特殊类型也继承:数组、枚举、注解等特殊类型也属于 Object 类型(如 String[] arr = new String[5]; arr instanceof Object 返回 true)。

本质:统一所有对象的基础行为:

Object 类封装了所有 Java 对象的通用能力,比如:

  • 判断对象是否相等(equals());
  • 生成对象的哈希值(hashCode());
  • 描述对象信息(toString());
  • 线程间通信(wait()/notify())。

这些方法保证了所有 Java 对象都具备统一的基础接口,是面向对象设计的“最小约定”。

核心特性

核心特性:

  • 无需手动导入java.lang 包下的类(包括 Object)由 JVM 自动导入,可直接使用;
  • 仅含无参构造:Object 只有一个无参构造方法,子类构造方法默认通过 super() 调用它;
  • 不可被重写的核心方法getClass()wait()notify() 等被 final 修饰,无法子类重写。

核心方法

Object 类的核心方法(逐个详解):

Object 类的方法可分为 5 大类,以下是每个方法的签名、默认实现、作用、重写规则和实战示例(新手重点掌握前 4 个方法):

== 运算符

在 Java 中,== 是一个比较运算符(Comparison Operator)。

虽然它看起来很简单,但它是 Java 新手最容易混淆的概念之一,因为它在处理基本数据类型引用数据类型时,行为截然不同。

我们可以用一句话总结它的核心逻辑:

== 永远比较的是变量内存中存储的“值”。

只是对于不同类型,“内存中的值”代表的意义不同。

基本数据类型

场景 1:基本数据类型 (Primitives):

(byte, short, int, long, float, double, char, boolean)

对于基本数据类型,变量在栈内存(Stack)中直接存储的是实际的数据值

  • 行为: == 比较的是它们的数值是否相等。
  • 规则: 只要数值一样,结果就是 true

代码示例:

java
int a = 10;
int b = 10;
double c = 10.0;

System.out.println(a == b); // true (10 等于 10)
System.out.println(a == c); // true (10 等于 10.0,自动类型提升后数值相等)

引用数据类型

场景 2:引用数据类型 (Reference Types):

(类、接口、数组)

对于引用数据类型,变量在栈内存(Stack)中存储的不是对象本身,而是对象在堆内存(Heap)中的内存地址(Reference)。

  • 行为: == 比较的是它们的内存地址
  • 本质: 它判断的是“这两个引用是否指向同一个物理对象”。
  • 比喻: 就像你有两把钥匙。== 问的是:“这两把钥匙是不是同一把(完全一模一样的那把金属)?”,而不是问“它们能不能开同一扇门?”。

代码示例:

java
// 在堆内存中创建了两个不同的对象,虽然内容一样
User u1 = new User("张三");
User u2 = new User("张三");
User u3 = u1; // u3 复制了 u1 的地址

System.out.println(u1 == u2); // false (地址不同,是两个独立的对象)
System.out.println(u1 == u3); // true  (指向同一个对象)

陷阱:String 字符串

特殊陷阱:String 字符串:

这是面试中关于 == 最著名的坑。由于 Java 对字符串进行了特殊优化(字符串常量池 String Constant Pool),导致 == 的结果有时让人困惑。

方式一:字面量创建 (Literal):

java
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // true!

原因: Java 发现常量池里已经有 "Hello" 了,就直接把 s2 指向了同一个地址。为了省内存,它们共享了同一个对象。

方式二:new 关键字创建:

java
String s3 = new String("Hello");
String s4 = new String("Hello");
System.out.println(s3 == s4); // false!

原因: new 关键字强制在堆内存中开辟一块新空间。不管内容是不是一样,地址绝对不同。

陷阱:包装类的缓存

进阶陷阱:包装类的缓存 (Integer Cache):

这是另一个高频考点。Java 的包装类(Integer, Long 等)也有缓存机制。

java
Integer i1 = 100;
Integer i2 = 100;
System.out.println(i1 == i2); // true (命中缓存 -128 到 127)

Integer i3 = 200;
Integer i4 = 200;
System.out.println(i3 == i4); // false (超出缓存范围,创建了新对象)

结论: 比较包装类对象时,永远不要用 ==,一定要用 equals(),否则可能会遇到这种“薛定谔的相等”。

== vs equals()

== vs equals() 终极对比:

维度== 运算符equals() 方法
类型Java 语言内置的操作符Object 类定义的方法
基本数据类型比较数值 (可用)编译错误 (不可用)
引用数据类型比较内存地址 (判断是否同一对象)默认比地址,重写后比内容
运行速度极快 (CPU 指令级比较)较慢 (涉及方法调用和逻辑判断)
空指针安全安全 (null == null 为 true)不安全 (调用 null.equals 会崩)

最佳实践总结

最佳实践总结:

  1. 基本类型: 只能用 ==
  2. 字符串 (String): 死都别用 ==,一定要用 equals()
  3. 包装类 (Integer, Long): 别用 ==,用 equals(),以防缓存坑。
  4. 枚举 (Enum): 可以用 ==,也可以用 equals()(枚举单例不仅值相等,地址也相等,官方甚至建议用 == 以避免空指针)。
  5. 自定义对象:
  • 想判断是不是“同一个对象实例”?用 ==
  • 想判断“业务含义是否相等”?用 equals()(前提是重写了它)。

toString()

public String toString():返回该对象的字符串表示

  • 无参数
  • 返回String,该对象的文本描述。结果应是一个“简洁但内容丰富,且易于阅读”的字符串。
  • 抛出:无。

基本示例

默认实现 vs 重写实现

java
class User {
  private int id;
  private String name;

  public User(int id, String name) {
    this.id = id;
    this.name = name;
  }

  // 场景2:重写 toString (推荐)
  @Override
  public String toString() {
    return "User{id=" + id + ", name='" + name + "'}";
  }
}

public class Main {
  public static void main(String[] args) {
    Object rawObj = new Object();
    User user = new User(1, "Alice");

    // 场景1:默认实现 (类名 + @ + 十六进制哈希)
    // 输出示例: java.lang.Object@74a14482
    System.out.println(rawObj.toString());

    // 场景2:重写后输出业务数据
    // 输出: User{id=1, name='Alice'}
    System.out.println(user); // 自动调用 toString()
  }
}

核心特性

  1. 默认实现的剖析

    Object 类中,toString()源代码如下:

    java
    public String toString() {
     return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

    它由三部分组成:

    • 类名:全限定类名(如 java.lang.Object)。
    • 分隔符@ 符号。
    • 身份标识:对象的哈希码的无符号十六进制表示。

    深度解读:对于未重写该方法的对象,打印出来的字符串(如 User@4554617c)实际上是该对象在 JVM 堆内存中的逻辑身份指纹。虽然它通常不直接等同于物理内存地址, 但在 HotSpot 虚拟机默认的 hashCode 实现中,它与对象的内存地址紧密相关。

  2. 隐式调用机制

    toString() 是 Java 中被调用最频繁的方法之一,因为它被深度集成到了语言特性中。

    • 字符串拼接"Data: " + obj 编译器会自动将被拼接对象转换为 obj.toString()(若为 null 则拼接 "null")。
    • 打印输出System.out.println(obj) 内部直接调用 String.valueOf(obj),进而调用 toString()
    • 调试工具:IDE(IntelliJ IDEA, Eclipse)在 Debug 模式下的变量视图中,默认展示的就是对象的 toString() 结果。

注意事项

  1. 无限递归(StackOverflowError)

    这是重写 toString 时最致命的坑。如果两个对象存在双向引用(例如:父子节点、ORM 框架中的多对一关系),且两者的 toString 都试图打印对方,就会瞬间导致栈溢出。

    java
    class Parent {
      Child child;
      @Override
      public String toString() { return "Parent{child=" + child + "}"; }
    }
    
    class Child {
      Parent parent;
      @Override
      public String toString() { return "Child{parent=" + parent + "}"; } // 💣 触发无限递归
    }

    解决方案:打破引用链。在 toString 中,对于反向引用的字段,不要直接打印对象,而是打印其 ID 或名称,或者使用 @ToString.Exclude (Lombok) 排除该字段。

  2. 数组的打印陷阱

    Java 数组也是对象,但它们没有重写 toString()。直接打印数组会输出难以理解的类型签名(如 [I@1b6d3586)。

    java
    int[] nums = {1, 2, 3};
    System.out.println(nums); // 输出 [I@xxxx (无意义)
    
    // 正确做法:
    System.out.println(java.util.Arrays.toString(nums)); // 输出 [1, 2, 3]
  3. 敏感信息泄露

    日志系统通常会记录对象的 toString 结果。切勿在 toString 中包含密码、密钥、身份证号等敏感字段。

扩展知识

  1. 自动化生成工具

    手写 toString 既繁琐又容易在新增字段时遗漏。推荐使用以下方案:

    • Lombok:使用 @ToString 注解,编译期自动生成代码。

      java
      import lombok.ToString;
      
      @ToString
      public class User {
        private String name;
        private int age;
        // 编译时,Lombok 会自动帮你生成标准的 toString 代码
      }
    • IDE 生成:IDEA 中 Alt + Insert -> toString()

      java
      @Override
      public String toString() {
        return "User{" +
          "name='" + name + '\'' +
          ", age=" + age +
          ", email='" + email + '\'' +
          '}';
      }
    • Apache Commons LangToStringBuilder.reflectionToString(this)(通过反射实现,方便但性能较差,生产环境慎用高频调用)。

  2. Java 14+ Records (记录类)

    Java 14 引入的 record 关键字定义的数据类,编译器会自动生成包含所有字段值的标准 toString(),格式清晰且规范。

    java
    public record Point(int x, int y) {}
    
    // 自动生成的 toString 输出:Point[x=10, y=20]

equals()

public boolean equals(Object obj):指示其他某个对象是否与此对象“逻辑相等”。

  • objObject,要与之进行比较的引用对象。
  • 返回:如果此对象与 obj 参数在逻辑上相同,则返回 true;否则返回 false
  • 抛出:无(但在实现不当时可能抛出 NullPointerExceptionClassCastException)。

基本示例

最佳实践模板:以下是遵循《Effective Java》建议的标准覆写模板,兼顾了性能、健壮性和类型安全。

java
public class Employee {
  private long id;
  private String name;
  private double salary;

  @Override
  public boolean equals(Object o) {
    // 1. 自反性检查:如果是同一个对象引用,直接返回 true(性能优化)
    if (this == o) return true;

    // 2. 类型检查:处理 null 并确保类型兼容
    // 注意:这里使用 instanceof 允许子类相等(如 Hibernate 代理对象),
    // 若要求严格类型匹配(拒绝子类),则改用 if (o == null || getClass() != o.getClass())
    if (!(o instanceof Employee)) return false;

    // 3. 类型转换
    Employee employee = (Employee) o;

    // 4. 关键域比较:基本类型用 ==,引用类型用 Objects.equals,浮点型用 Float/Double.compare
    return id == employee.id &&
      Double.compare(employee.salary, salary) == 0 && // 处理 NaN 和 -0.0
      java.util.Objects.equals(name, employee.name);
  }

  // 警告:覆写 equals 必须覆写 hashCode
  @Override
  public int hashCode() {
    return java.util.Objects.hash(id, name, salary);
  }
}

核心特性

  1. 引用相等 vs 逻辑相等

    Object 类中默认的 equals 实现仅仅是检查引用相等性(Reference Equality),即比较内存地址(this == obj)。

    然而,对于像 StringIntegerDate 以及自定义的数据类(DTO/Entity),我们通常关注的是逻辑相等性(Logical Equality)。即只要两个对象的关键状态(成员变量)一致,它们就被视为相等,即使它们存储在堆内存的不同位置。

  2. 数学上的等价关系(Equivalence Relation)

    Java 语言规范(JLS)严格定义了 equals 方法必须满足的五个特性。任何违反这些特性的实现都会导致 HashMap、HashSet 或 Collections.sort 等基础类库出现不可预知的行为。

    • 自反性 (Reflexive):对于任何非空引用 x,x.equals(x) 必须返回 true。

    • 对称性 (Symmetric):对于任何非空引用 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 必须返回 true。

    • 传递性 (Transitive):如果 x.equals(y) 为 true 且 y.equals(z) 为 true,则 x.equals(z) 必须返回 true。

    • 一致性 (Consistent):对于任何非空引用 x 和 y,只要对象中的信息没有被修改,多次调用 x.equals(y) 必须一致地返回 true 或 false。

    • 非空性 (Non-nullity):对于任何非空引用 x,x.equals(null) 必须返回 false。

注意事项

  1. hashCode 协定 (The Golden Rule)

    这是 Java 中最常见也是最严重的 Bug 来源之一。

    契约规定:如果两个对象根据 equals(Object) 方法是相等的,那么调用这两个对象中任一对象的 hashCode 方法必须产生相同的整数结果。

    如果在覆写 equals 时忽略了 hashCode,使用该类的对象作为 HashMap 的 Key 或存入 HashSet 时,会导致哈希逃逸(Hash Escape):即使两个对象逻辑相同,由于哈希值不同,它们会被映射到哈希表的不同桶(Bucket)中,导致 get() 返回 null 或 add() 产生重复数据。

    java
    // 错误示范:只重写 equals 未重写 hashCode
    Person p1 = new Person(1, "Jack");
    Person p2 = new Person(1, "Jack");
    
    Map<Person, String> map = new HashMap<>();
    map.put(p1, "Data");
    
    // 灾难现场:虽然 p1.equals(p2) 为 true,但 p2 计算出的 hash 不同
    // 导致无法在 Map 中定位到 p1 存放的数据
    System.out.println(map.get(p2)); // 输出 null
  2. 浮点数的特殊性

    对于 floatdouble 类型的字段,切勿直接使用 == 进行比较

    • Float.NaNDouble.NaN 不等于自身(NaN == NaN 为 false),但 equals 规范要求自反性。
    • 0.0f-0.0f 是相等的(== 为 true),但它们在哈希表中应该被视为不同的键。

    解决方案:使用 Float.compare(f1, f2)Double.compare(d1, d2),或者直接转换成 long (Double.doubleToLongBits) 进行比较。

  3. 继承中的对称性崩塌

    当一个类继承自通过 instanceof 实现 equals 的父类,并添加了新的值组件(Value Component)时,无法同时满足对称性和传递性。

    • 如果子类 equals 试图比较新字段,则 super.equals(sub) 忽略新字段返回 true,而 sub.equals(super) 检查新字段返回 false(违反对称性)。
    • 如果为了满足对称性忽略类型差异,往往会牺牲传递性。

    解决方案

    1. 使用 组合优先于继承(Composition over Inheritance)。

    2. 如果必须继承,且父类和子类都需要参与比较,考虑将 equals 定义为 final,或者在父类中使用 getClass() 代替 instanceof(但这违反了里氏替换原则,导致子类对象无法等于父类对象)。

扩展知识

  1. Lombok 与 AutoValue

    在实际工程中,手写 equalshashCode 容易出错且繁琐。强烈建议使用 Lombok 的 @EqualsAndHashCode 注解或 Google 的 AutoValue 框架自动生成代码。

  2. Java 14+ Records

    Java 14 引入的 record(记录类型)会自动生成正确的 equalshashCodetoString 方法,非常适合用于仅作为数据载体的类(DTO)。

    java
    public record Point(int x, int y) { }
    // 自动拥有基于 x 和 y 的 equals 实现

hashCode()

public native int hashCode():返回该对象的哈希码数值

  • 参数:无。
  • 返回int,一个代表该对象特征的 32 位整数。
  • 抛出:无。

基本示例

标准实现范式:以下展示了不依赖第三方库的手动实现方式(性能最优),以及现代 IDE 自动生成的标准逻辑。

java
public class User {
  private int id;
  private String name;
  private boolean active;

  @Override
  public int hashCode() {
    // 1. 初始化一个非零常数 (通常是质数,如 17)
    int result = 17;

    // 2. 为每个参与 equals 的字段计算哈希并累加
    // 核心公式:result = 31 * result + fieldHash

    // 基本类型直接转换或计算
    result = 31 * result + id;
    result = 31 * result + (active ? 1 : 0);

    // 引用类型调用其自身的 hashCode (需处理 null)
    result = 31 * result + (name != null ? name.hashCode() : 0);

    return result;
  }

  // 简易写法 (性能稍差,因为会创建 Object[] 数组导致 GC 压力):
  // return Objects.hash(id, name, active);
}

核心特性

  1. 协定与一致性 (The Contract)

    hashCode 的核心存在意义是为了支撑基于散列的集合(Hash-based Collections,如 HashMap, HashSet, ConcurrentHashMap)的高效运作。Java 规范对其有严格的三条协定:

    • 一致性:在 Java 应用的一次执行过程中,只要对象 equals 比较所用的信息未被修改,多次调用 hashCode 必须返回相同的整数。
    • 相等性映射:如果 x.equals(y) 返回 true,则 xyhashCode() 必须相等。
    • 碰撞允许性:如果 x.equals(y) 返回 false,并不要求 xyhashCode() 必须不同。但在哈希表中,不相等的对象具有不同的哈希码可以显著提高性能(减少哈希碰撞)。
    java
    public static void main(String[] args) {
      AA aa = new AA();
      AA aa2 = new AA();
      AA aa3 = aa;
    
      // 1. aa 和 aa2 指向不同的对象,它们的 hashCode 值也不同
      System.out.println(aa.hashCode() == aa2.hashCode()); // false
    
      // 2. aa 和 aa3 指向同一个对象,它们的 hashCode 值相同
      System.out.println(aa.hashCode() == aa3.hashCode()); // true
    }
  2. 为什么是 31 (The Magic Number)

    在手动生成 hashCode 时,你会发现几乎所有的 IDE(IntelliJ, Eclipse)和 JDK 源码(如 String 类)都使用 31 作为乘数。

    • 质数特性:31 是一个奇质数。如果乘数是偶数,乘法溢出相当于左移,会导致信息丢失(低位被补 0)。质数能更好地保留各字段的特征,降低哈希碰撞概率。
    • 位运算优化:31 有一个很好的位运算特性,即 31 * i == (i << 5) - i。现代 JVM 的 JIT 编译器能自动将乘法优化为移位和减法操作,在底层 CPU 指令级别执行效率极高。
  3. 默认行为 (Identity Hash Code)

    如果一个类没有重写 hashCode(),它将调用 Object 类的本地 (native) 实现。

    • 在 HotSpot JVM 中,默认实现并不总是直接返回内存地址(因为对象在 GC 时会移动,地址会变)。
    • 它通常是基于线程状态、随机数或内存地址计算出的一个身份哈希码,并存储在对象头(Object Header)的 Mark Word 中。一旦计算过一次,该值就会被固化在对象头中,即使对象被 GC 移动,该值也不再改变。

注意事项

  1. 可变对象的哈希灾难

    切勿使用可变字段作为计算 hashCode 的依据,尤其是在该对象已经存入 Set 或 Map 之后。

    这是新手最容易踩的坑。如果在对象存入 HashMap 后修改了参与 hashCode 计算的字段,对象的哈希值会发生变化,但它在 Map 中的位置(桶下标)还是旧的。这会导致你明明拿着完全相同的对象去 getremove,却返回 null 或无法删除,造成内存泄漏。

    java
    // 错误示范:修改了参与 hash 计算的字段
    User u = new User(1, "Jack");
    Set<User> set = new HashSet<>();
    set.add(u); // 此时根据 hash(Jack) 放入 bucket A
    
    u.setName("Rose"); // 修改字段,hashCode 变了!
    
    // 虽然 u 还在 set 里,但 contains 会去 bucket B 找,结果找不到
    System.out.println(set.contains(u)); // 输出 false (这是个严重的 Bug)
  2. DoS 攻击风险

    如果你的 hashCode 实现过于简单(例如直接返回字段值)或可预测,恶意用户可以构造大量具有相同哈希值的对象(哈希碰撞)。这将导致 HashMap 退化为链表(或红黑树),将查找时间复杂度从 O(1) 拖慢至 O(n) 或 O(log n),从而消耗大量 CPU 资源,造成 Hash DoS 攻击

    • 防护:JDK 8 之后的 HashMap 引入了红黑树机制来缓解此问题,但在高安全需求的场景下,需谨慎设计哈希算法。
  3. 性能权衡

    • 缓存哈希值:对于不可变类(Immutable Class,如 String),如果在 hashCode 计算开销很大,建议将计算结果缓存起来(Lazy Initialization)。
    • 避免自动装箱:在高性能场景下,尽量避免使用 Objects.hash() 处理基本数据类型,因为它会触发自动装箱和数组创建。

扩展知识

  • System.identityHashCode()

    即使一个对象重写了 hashCode(),你依然可以通过 System.identityHashCode(obj) 获取该对象原本的、由 JVM 提供的默认哈希码。这在判断两个引用是否指向绝对同一个对象实例(不仅仅是逻辑相等)时非常有用。

    java
    String s = new String("Hello");
    // String 重写了 hashCode,基于内容计算
    System.out.println(s.hashCode());
    // 获取基于对象身份的原始 hash,类似 Object.hashCode()
    System.out.println(System.identityHashCode(s));

getClass()

public final native Class<?> getClass():返回此 Object 的运行时类

  • 无参数
  • 返回Class<?>,表示此对象运行时类的 Class 对象。返回的 Class 对象是被该对象所属的类加载器加载的那个单例对象。
  • 抛出:无(但在 null 对象上调用会抛出 NullPointerException)。

基本示例

示例关键词:多态、运行时类型识别

java
public class Main {
  public static void main(String[] args) {
    Object str = "Hello Java";
    Object num = 100; // 自动装箱为 Integer

    // 尽管引用类型都是 Object,但 getClass() 返回实际的运行时类型
    System.out.println("str 运行时类: " + str.getClass().getName());
    // 输出: java.lang.String

    System.out.println("num 运行时类: " + num.getClass().getName());
    // 输出: java.lang.Integer

    // 验证 Class 对象的单例性
    System.out.println(str.getClass() == String.class); // true
  }
}

核心特性

  1. 运行时类型识别 (RTTI) 与多态

    getClass() 是 Java 运行时类型识别(RTTI)的核心机制之一。与引用变量声明的静态类型(Static Type)不同,getClass() 返回的是对象在堆内存中实际创建的动态类型(Dynamic Type)。

    这意味着,即使你将一个子类对象赋值给父类引用,getClass() 依然能洞察其本质。这是通过 JVM 在对象头中维护元数据来实现的。

  2. JVM 底层实现:对象头与 Klass Pointer

    该方法是一个 native 方法,其实现直接映射到 JVM 内部。

    • 对象布局:在 HotSpot 虚拟机中,Java 对象在堆内存中的布局包含“对象头”(Object Header)。
    • 类型指针:对象头中包含一个类型指针(Klass Pointer / _klass),它指向方法区(Metaspace)中该类的元数据对象(Klass 结构)。
    • 映射机制:当你调用 getClass() 时,JVM 不会去分析代码,而是直接读取对象头中的这个指针,找到对应的 Class 对象并返回。这就是为什么它非常快且不可伪造。
  3. Final 不可重写性

    该方法被声明为 final。这是为了保证 Java 类型系统的安全性和一致性。如果允许子类重写 getClass() 并返回错误的类型(例如 String 的子类谎称自己是 Integer),将导致 JVM 的类型检查机制、反射机制以及安全沙箱完全崩溃。

注意事项

  1. 泛型类型擦除 (Type Erasure)

    这是 getClass() 最常见的误区。Java 的泛型是伪泛型,在编译后会进行类型擦除。因此,你无法通过 getClass() 获取泛型的具体参数类型。

    java
    List<String> list1 = new ArrayList<>();
    List<Integer> list2 = new ArrayList<>();
    
    // 看起来是不同的类型,但运行时类完全相同
    System.out.println(list1.getClass() == list2.getClass()); // 输出 true
    System.out.println(list1.getClass()); // 输出 class java.util.ArrayList
    // 无法获取 <String> 或 <Integer> 信息
  2. equals() 方法中的使用陷阱:getClass vs instanceof

    在重写 equals 方法时,使用 getClass() 还是 instanceof 决定了比较的严格程度

    • getClass():要求两个对象必须是完全相同的类。这违反了里氏替换原则(LSP),因为子类无法等于父类,即便它们逻辑上相等。

    • instanceof:允许子类与父类进行比较(只要子类是父类的实例)。

      java
      class Point { int x, y; }
      class ColorPoint extends Point { int color; }
       
      Point p = new Point();
      ColorPoint cp = new ColorPoint();
       
      // 如果 Point.equals 使用 getClass():
      p.equals(cp); // return false (因为 Point.class != ColorPoint.class)
       
      // 如果 Point.equals 使用 instanceof:
      p.equals(cp); // 可能 return true (取决于逻辑,但类型检查是通过的)

    建议:如果你希望子类对象在逻辑上等同于父类对象(如 Hibernate 代理对象),请慎用 getClass(),改用 instanceof。如果你需要绝对的类型匹配,才使用 getClass()

  3. 空指针异常风险

    getClass() 是实例方法,必须通过对象实例调用。如果引用为 null,JVM 无法通过对象头寻找类型信息,因此会立即抛出 NullPointerException

    java
    Object obj = null;
    // obj.getClass(); // 抛出 NPE
    
    // 安全替代方案(仅用于检查,非获取 Class)
    // java.util.Objects.requireNonNull(obj);

扩展知识

  1. 关联知识:.class 字面量 vs getClass()

    • ClassName.class:在编译期确定。如果类尚未加载,会触发类加载器加载该类,但不会初始化(不执行静态代码块,取决于具体 JVM 实现和使用场景,通常仅引用 .class 不会触发初始化,但 Class.forName 会)。
    • obj.getClass():在运行期确定。前提是必须先有一个实例化对象。
  2. 数组的特殊性

    数组也是对象,也有 getClass()。不同维度的数组、不同类型的数组,其 Class 对象是不同的。

    java
    int[] arr1 = new int[10];
    int[][] arr2 = new int[10][10];
    double[] arr3 = new double[10];
    
    System.out.println(arr1.getClass().getName()); // [I (代表 int[])
    System.out.println(arr2.getClass().getName()); // [[I (代表 int[][])
    System.out.println(arr1.getClass() == arr2.getClass()); // false
  3. 反射入口

    getClass() 通常是反射操作的第一步。拿到 Class 对象后,你可以调用 getMethods(), getDeclaredFields(), getConstructor() 等方法来动态操纵代码。

clone()

protected native Object clone():创建并返回此对象的一个副本。

  • 无参数
  • 返回Object,该对象的拷贝。
  • 抛出CloneNotSupportedException - 如果对象的类不支持 Cloneable 接口,则抛出此异常。

基本示例

示例关键词:实现 Cloneable、提升访问权限

java
// 1. 必须实现 Cloneable 接口(标记接口),否则抛异常
class Person implements Cloneable {
  String name;
  int age;

  public Person(String name, int age) {
    this.name = name;
    this.age = age;
  }

  // 2. 必须重写 clone 方法,并将 protected 改为 public
  @Override
  public Object clone() {
    try {
      // 3. 调用 super.clone() 获取底层拷贝
      return super.clone();
    } catch (CloneNotSupportedException e) {
      // 理论上不会发生,因为我们实现了 Cloneable
      throw new AssertionError();
    }
  }
}

public class Main {
  public static void main(String[] args) {
    Person p1 = new Person("Java", 25);
    // 调用 public 的 clone 方法
    Person p2 = (Person) p1.clone();

    System.out.println(p1 != p2); // true (地址不同)
    System.out.println(p1.getClass() == p2.getClass()); // true
  }
}

核心特性

  1. 浅拷贝 (Shallow Copy) 机制

    Object.clone() 的默认实现是浅拷贝

    • 底层逻辑:JVM 会在堆内存中开辟一块新的内存空间,大小与原对象一致,然后直接将原对象内存块中的二进制数据(Bitwise Copy)复制到新内存中。

    • 值传递

    • 对于基本数据类型(int, long, boolean 等),复制其具体的值。

    • 对于引用数据类型(String, List, 对象引用),复制的是内存地址(引用)

    • 后果:新旧对象共享同一个引用类型的成员变量。如果其中一个修改了该引用指向的可变对象内部数据,另一个也会受到影响。

  2. Cloneable 接口的魔法

    Cloneable 是一个没有任何方法的标记接口(Marker Interface)。

    • JVM 检查:当调用 Object.clone() 时,JVM 会检查该对象的类是否实现了 Cloneable

    • 行为差异

    • 若已实现:JVM 执行内存复制操作。

    • 若未实现:JVM 直接抛出 CloneNotSupportedException

    • 设计缺陷:通常接口定义的是“能做什么”(Capabilities),但 Cloneable 却修改了父类 Objectclone 方法的行为,这被公认为 Java 早期的一个设计瑕疵。

  3. 不调用构造器

    clone() 是极其特殊的实例化方式。它不会调用构造方法

    • 原理:它是直接的内存复制。
    • 风险:如果你的构造器中有复杂的初始化逻辑(如计数器自增、资源绑定、依赖注入),使用 clone() 将会绕过这些逻辑,产生不完整的对象状态。

注意事项

  1. 深拷贝 (Deep Copy) 的实现难题

    如果对象包含可变的引用类型字段(如 ArrayListDate 或自定义对象),必须手动实现深拷贝,否则会产生严重的副作用。

    java
    class Team implements Cloneable {
      String name;
      ArrayList<String> members; // 可变引用类型
    
      @Override
      public Object clone() {
        try {
          Team cloned = (Team) super.clone();
          // 必须手动对可变引用字段再次 clone
          // 否则 cloned.members 和 this.members 指向同一个 List
          cloned.members = (ArrayList<String>) this.members.clone();
          return cloned;
        } catch (CloneNotSupportedException e) {
          throw new AssertionError();
        }
      }
    }
  2. protected 访问权限的限制

    Object 中的 clone()protected 的。这意味着你不能直接调用外部对象的 clone() 方法,除非该对象明确重写并公开了该方法。

    java
    Object obj = new Object();
    // obj.clone(); // 编译错误!'clone()' has protected access in 'java.lang.Object'
  3. Final 字段的冲突

    如果类中包含 final 修饰的可变引用字段,实现深拷贝将非常困难。因为 final 字段一旦赋值(在 super.clone() 的浅拷贝阶段已赋值),就无法在 clone 方法中再次赋值(指向新的深度拷贝对象)。

    • 解决方案:去掉 final 修饰符,或者不使用 clone() 机制。

扩展知识

  1. 最佳实践:拷贝构造器 (Copy Constructor)

    鉴于 clone() 的诸多设计缺陷(异常检查、类型转换、浅拷贝陷阱、构造器绕过),《Effective Java》建议慎用 clone,推荐使用拷贝构造器或静态工厂方法。

    java
    // 推荐方案:清晰、可控
    public class User {
      private String name;
      private Date birth;
    
      // 拷贝构造器
      public User(User other) {
        this.name = other.name;
        // 手动处理深拷贝逻辑,清晰明了
        this.birth = new Date(other.birth.getTime());
      }
    }
  2. 序列化实现深拷贝

    对于复杂的对象图,手动递归重写 clone 非常容易出错。一种“偷懒”但性能较低的深拷贝方式是利用序列化(Serialization)。

    • 原理:将对象写出到字节流,再从字节流读回来。
    • 工具Apache Commons LangSerializationUtils.clone(obj) 或使用 JSON 库(Jackson/Gson)进行转存。

wait()

public final void wait() throws InterruptedException:导致当前线程进入等待状态,直到另一个线程调用此对象的 notify()notifyAll() 方法。

  • 重载版本

    • wait(long timeout):等待指定毫秒数。
    • wait(long timeout, int nanos):等待指定毫秒数加纳秒数。
  • 返回void

  • 抛出

    • InterruptedException - 如果任何线程在当前线程等待之前或期间中断了当前线程。
    • IllegalMonitorStateException - 如果当前线程不是此对象监视器(锁)的所有者。

基本示例

标准等待/通知模式(Producer-Consumer 简化版)

此示例展示了 wait() 必须配合 synchronizedwhile 循环使用的标准范式。

java
public class WaitNotifyDemo {
  private static final Object lock = new Object();
  private static boolean isReady = false;

  public static void main(String[] args) {
    Thread consumer = new Thread(() -> {
      synchronized (lock) {
        // 1. 必须在循环中检查条件 (防止虚假唤醒)
        while (!isReady) {
          try {
            System.out.println("Consumer: 条件不满足,开始 wait...");
            // 2. 释放锁并挂起
            lock.wait();
            System.out.println("Consumer: 被唤醒,重新获得锁");
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
          }
        }
        System.out.println("Consumer: 执行业务逻辑");
      }
    });

    Thread producer = new Thread(() -> {
      try { Thread.sleep(1000); } catch (InterruptedException e) {}
      synchronized (lock) {
        System.out.println("Producer: 准备数据...");
        isReady = true;
        // 3. 唤醒等待线程(注意:此时锁并未立即释放,需执行完同步块)
        lock.notify();
        System.out.println("Producer: 已发出通知");
      }
    });

    consumer.start();
    producer.start();
  }
}

核心特性

  1. Monitor 机制与 Wait Set

    Java 对象头(Object Header)关联了一个监视器(Monitor)。Monitor 内部维护了两个重要的队列:

    • Entry Set(锁池):等待获取锁的线程队列。
    • Wait Set(等待池):调用了 wait() 的线程队列。

    当线程调用 wait() 时,它会:

    1. 释放当前持有的对象锁(这是与 Thread.sleep 的核心区别)。
    2. 进入该对象的 Wait Set
    3. 线程状态变为 WAITINGTIMED_WAITING,并不再参与 CPU 调度。

    只有当其他线程调用 notify()(随机唤醒一个)或 notifyAll()(唤醒所有)时,线程才会从 Wait Set 移动到 Entry Set,重新竞争锁。

  2. 虚假唤醒(Spurious Wakeup)

    操作系统层面的条件变量(Condition Variable)实现可能会在没有收到 notify 信号的情况下莫名其妙地唤醒线程。这是底层 OS 的一种允许行为(通常为了性能或处理信号)。

    因此,Java 规范强制要求:wait() 应该总是在循环中调用

    java
    // 正确写法:循环检查
    synchronized (obj) {
      while (<condition does not hold>)
        obj.wait();
      // Perform action appropriate to condition
    }
    
    // 错误写法:if 检查
    synchronized (obj) {
      if (<condition does not hold>)
        obj.wait(); // 如果发生虚假唤醒,或者被错误唤醒,这里将直接执行,导致逻辑错误
      // ...
    }
  3. 原子性释放

    wait() 方法不仅挂起线程,还是一个原子操作(Atomic Operation),它保证了“释放锁”和“进入等待状态”之间不会插入其他指令。这避免了“丢失信号”的问题(即在判断条件和挂起之间,另一个线程刚好发出了 notify)。

注意事项

  1. IllegalMonitorStateException

    wait()notify()notifyAll() 必须在同步代码块(synchronized)内部调用,且调用对象必须是锁对象本身。

    如果不持有该对象的锁直接调用 wait(),JVM 会抛出 IllegalMonitorStateException。这是为了确保线程安全地修改共享的条件变量。

  2. wait() vs Thread.sleep()

    这是最高频的面试题,本质区别在于锁的处理

    特性Object.wait()Thread.sleep()
    所属类Object (实例方法)Thread (静态方法)
    锁行为释放当前持有的锁不释放任何锁 (抱着锁睡觉)
    使用场景线程间通信/协作暂停执行/时间控制
    唤醒方式notify(), notifyAll(), 中断时间到, 中断
    前置条件必须持有 Monitor 锁无需持有锁
    java
    // 错误示范:在 synchronized 中 sleep 导致死锁风险
    synchronized(lock) {
      // 其他线程无法获得 lock,整个系统卡死
      Thread.sleep(10000);
    }

扩展知识

  1. JUC Condition (现代替代方案)

    在 JDK 5 引入 java.util.concurrent 包后,推荐使用 LockCondition 接口来替代原始的 wait/notify

    Condition 提供了更灵活的等待队列:

    • 多路通知:一个 Lock 可以创建多个 Condition(例如 notFullnotEmpty),从而实现精准唤醒(例如生产者只唤醒消费者),而 Object.notify 是随机唤醒,效率较低。

    • await() / signal():对应 wait() / notify()

      java
      Lock lock = new ReentrantLock();
      Condition condition = lock.newCondition();
      
      lock.lock();
      try {
        while (!ready) {
          condition.await(); // 释放 lock
        }
      } finally {
        lock.unlock();
      }

notify()

public final void notify():唤醒在此对象监视器(Monitor)上等待的单个线程。

  • 返回void
  • 抛出IllegalMonitorStateException - 如果当前线程不是此对象监视器的所有者(即没有在 synchronized 块中调用)。

基本示例

精准唤醒(配合 wait 使用)

此示例展示了 notify() 如何将挂起的线程“复活”。注意观察输出顺序,证明 notify 后并不会立即释放锁。

java
public class NotifyDemo {
  private static final Object lock = new Object();

  public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
      synchronized (lock) {
        System.out.println("T1: 获得锁,准备 wait");
        try {
          lock.wait(); // 释放锁,进入 Wait Set
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        System.out.println("T1: 被唤醒,重新获得锁,继续执行");
      }
    });

    Thread t2 = new Thread(() -> {
      try { Thread.sleep(1000); } catch (InterruptedException e) {}
      synchronized (lock) {
        System.out.println("T2: 获得锁,准备 notify");
        lock.notify(); // 唤醒 T1,但 T2 此时仍持有锁
        System.out.println("T2: 已发出通知,但还要睡 2秒...");
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
        System.out.println("T2: 释放锁");
      } // T2 退出同步块后,T1 才有机会竞争锁
    });

    t1.start();
    t2.start();
  }
}

核心特性

  1. 监视器状态迁移(Wait Set -> Entry Set)

    notify() 的本质操作并非立即运行线程,而是改变线程的存储位置。

    • Wait Set(等待池):调用 wait() 的线程在此休眠。
    • Entry Set(锁池):等待获取锁的线程在此排队。

    当调用 obj.notify() 时,JVM 会随机(取决于具体实现,通常不可预测)从 obj 的 Wait Set 中选择一个线程,将其移动到 Entry Set。

    关键点:被唤醒的线程不会立即执行,它必须等待当前持有锁的线程(即调用 notify 的线程)释放锁,然后在 Entry Set 中与其他线程再次竞争锁的所有权。

  2. 选择策略的任意性

    Java 规范并未规定 notify() 必须唤醒哪一个等待线程(例如,不保证是等待时间最长的那个)。这意味着在特定的 JVM 实现中,这种选择可能是随机的。

    结论:如果你依赖特定的唤醒顺序,绝对不要使用 notify(),而应使用 Lock + Condition 或其他并发工具。

  3. 非即时释放锁

    这是一个极其常见的误区。调用 notify() 是一条极其轻量的指令,它不会导致当前线程释放锁。当前线程必须执行完 synchronized 代码块的所有剩余语句,或者显式调用 wait(),锁才会被释放,被唤醒的线程才真正有机会运行。

    java
    synchronized(lock) {
     lock.notify();
     // 此时被唤醒的线程依然被阻塞在 Entry Set 中
     // 因为当前线程还没有走出 synchronized 块
     heavyCalculation();
    }
    // 代码块结束,锁释放,被唤醒线程开始竞争

注意事项

  1. 信号丢失(Missed Signal)风险

    如果 notify()wait() 之前执行,该信号将被直接丢弃。因为 notify 针对的是当前的 Wait Set,如果 Wait Set 为空,这个通知就“虽然发出了但无人接收”。这会导致后来的 wait() 线程永远等待下去。

    解决方案:始终使用变量标识状态(如 boolean isReady),并在 wait 前检查该状态(即标准的 while 循环模式)。

  2. Notify vs NotifyAll(死锁风险)

    这是 notify() 最危险的坑。notify() 每次只唤醒一个线程。在“多生产者-多消费者”场景下,如果所有线程都在同一个锁上等待,极易发生信号劫持

    场景

    • 消费者 A 被唤醒,消费了一个元素,释放锁并发出 notify()
    • 本意是唤醒生产者 B 生产数据。
    • 结果运气不好,唤醒了另一个消费者 C。
    • 消费者 C 发现队列为空,于是 wait()
    • 此时,所有线程(包括生产者)都在 wait,系统陷入死锁

    黄金法则:除非你非常确定只唤醒同一类线程(且每次唤醒一个就能满足逻辑),否则永远优先使用 notifyAll()

扩展知识

  1. Condition.signal() 的优势

    为了解决 Object.notify() 无法区分唤醒对象的问题,JDK 5 引入了 Condition

    java
    ReentrantLock lock = new ReentrantLock();
    Condition notFull  = lock.newCondition(); // 专门存放生产者的队列
    Condition notEmpty = lock.newCondition(); // 专门存放消费者的队列
    
    // 生产者只唤醒消费者,避免了唤醒同类的无用功
    notEmpty.signal();

notifyAll()

public final void notifyAll():唤醒在此对象监视器(Monitor)上等待的所有线程。

  • 返回void
  • 抛出IllegalMonitorStateException - 如果当前线程不是此对象监视器的所有者(即没有在 synchronized 块中调用)。

基本示例

“发令枪”模式(One-Shot Latch)

此示例演示了 notifyAll() 如何一次性激活所有挂起的线程。与之相比,notify() 只会唤醒其中一个,导致其他线程“永久沉睡”。

java
public class NotifyAllDemo {
  private static final Object lock = new Object();
  private static boolean startSignal = false;

  public static void main(String[] args) throws InterruptedException {
    // 启动 5 个运动员线程
    for (int i = 0; i < 5; i++) {
      new Thread(() -> {
        synchronized (lock) {
          try {
            System.out.println(Thread.currentThread().getName() + " 准备就绪,等待发令...");
            while (!startSignal) {
              lock.wait(); // 所有线程在此挂起
            }
            System.out.println(Thread.currentThread().getName() + " 起跑!");
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
          }
        }
      }, "Athlete-" + i).start();
    }

    Thread.sleep(1000); // 确保所有运动员都已进入 wait 状态

    synchronized (lock) {
      System.out.println("裁判:各就各位... 预备... 跑!(调用 notifyAll)");
      startSignal = true;
      lock.notifyAll(); // 唤醒所有等待在 lock 上的 5 个线程
    }
  }
}

核心特性

  1. 全量迁移(Wait Set -> Entry Set)

    调用 notifyAll() 时,JVM 会将该对象 Wait Set(等待池) 中的所有线程全部移入 Entry Set(锁池)

    • 状态变更:线程状态从 WAITING 变为 BLOCKED
    • 竞争机制:这些被唤醒的线程不会同时运行,而是必须在当前线程释放锁之后,在 Entry Set 中与其他线程(以及可能新来的线程)共同竞争这把锁。
    • 执行顺序:最终只有一个线程能抢到锁并执行 wait() 之后的代码,其他失败者继续在 Entry Set 中阻塞等待下一次锁释放。
  2. 解决“信号劫持”与死锁

    这是 notifyAll() 存在的最大意义。在多生产者-多消费者场景中,如果使用 notify(),可能会出现“消费者唤醒了另一个消费者”的情况(同类唤醒),导致生产者一直处于等待状态,最终所有线程都在等待,形成死锁(Deadlock)。

    使用 notifyAll() 的逻辑

    • 虽然唤醒了所有消费者和生产者,效率看似低了。
    • 但是,它保证了至少有一个正确的线程(比如生产者)被唤醒并能够推进状态(生产数据),从而打破僵局。
    • 结论:除了极简单的 1 对 1 通信,默认应使用 notifyAll() 以确保程序的活跃性(Liveness)。

注意事项

  1. 惊群效应(Thundering Herd Problem)

    这是 notifyAll() 的主要性能弊端。

    • 现象:你唤醒了 100 个线程,但只有一个能抢到锁并处理任务(例如队列里只来了一个数据)。
    • 代价:剩下的 99 个线程虽然被唤醒了,但抢锁失败后又得重新阻塞,或者抢到锁后发现条件不满足(while 循环检查)又调用 wait() 挂起。
    • 结果:导致大量的 CPU 上下文切换(Context Switch)和无效的锁竞争,造成系统负载瞬间飙升。
  2. 必须配合 while 循环

    即使使用了 notifyAll(),所有被唤醒的线程也会依次获取锁。

    • 线程 A 抢到锁,消费了数据,将条件置为 false。
    • 线程 A 释放锁。
    • 线程 B(也被 notifyAll 唤醒了)抢到锁。
    • 如果线程 B 使用 if 判断,它会直接往下执行(即使数据已经被 A 消费完了),导致逻辑错误。
    • 因此,必须使用 while 循环,让 B 醒来后再次检查条件,发现没数据了,乖乖回去 wait()

扩展知识

  1. JUC Condition 的精准通知

    为了解决 notifyAll() 的“惊群效应”,java.util.concurrent.locks.Condition 提供了分组唤醒机制。

    • lock.newCondition() 可以创建多个等待队列。

    • 优化方案:将生产者放在 notFull 队列,消费者放在 notEmpty 队列。

    • 精准打击:生产者生产完数据,只调用 notEmpty.signalAll()(唤醒所有消费者),而不打扰其他生产者。

      java
      // 优化的架构
      private final Lock lock = new ReentrantLock();
      private final Condition notFull  = lock.newCondition();
      private final Condition notEmpty = lock.newCondition();
      
      public void put(Object x) throws InterruptedException {
        lock.lock();
        try {
          while (count == items.length)
            notFull.await();
          // ... 生产逻辑
          notEmpty.signal(); // 只通知消费者
        } finally {
          lock.unlock();
        }
      }

finalize()@Deprecated

protected void finalize() throws Throwable:[已废弃] 当垃圾回收器(GC)确定不存在对该对象的更多引用时,由 GC 在对象上调用此方法。子类可以重写finalize方法来处理系统资源的释放或执行其他清理操作。

  • 返回void
  • 抛出Throwable — 此方法抛出的任何异常都会被 GC 线程捕获并忽略,不会导致程序终止。
  • 状态Deprecated (Since Java 9, Marked for Removal)。在 JEP 421 中被标记为“终结过时”,未来版本将被彻底删除。

基本示例

模拟资源回收(仅作原理演示,严禁生产使用)

此代码演示了 finalize 的触发机制及其执行的不确定性。

java
public class FinalizeDemo {

  static class HeavyResource {
    private String name;

    public HeavyResource(String name) {
      this.name = name;
    }

    // 子类可以重写`finalize`方法来处理系统资源的释放或执行其他清理操作。
    @Override
    protected void finalize() throws Throwable {
      // 务必调用 super.finalize(),虽然 Object 中是空的,但这是好习惯
      super.finalize();
      System.out.println("Resource [" + name + "] is being finalized by thread: "
                         + Thread.currentThread().getName());
    }
  }

  public static void main(String[] args) throws InterruptedException {
    HeavyResource resource = new HeavyResource("DB-Connection");

    // 1. 断开引用,使其变为不可达(Unreachable)
    resource = null;

    // 2. 建议 JVM 进行垃圾回收(仅是建议,不保证立即执行)
    System.gc(); // 非阻塞的异步方法

    // 3. 必须等待,因为 Finalizer 线程优先级极低
    System.out.println("Main thread waiting...");
    Thread.sleep(1000);
  }
}

核心特性

  1. F-Queue 与 Finalizer 线程机制(GC 回收过程)

    当一个重写了 finalize() 方法的对象被创建时,JVM 会创建一个额外的 java.lang.ref.Finalizer 对象指向它。

    • 第一阶段(标记):GC 发现对象不可达,且该对象重写了 finalize 方法,将其放入 F-Queue(Finalization Queue)。
    • 第二阶段(执行):一个低优先级的守护线程(Finalizer 线程)会不断从 F-Queue 中取出对象,并执行其 finalize() 方法。
    • 第三阶段(回收):只有在 finalize() 执行完毕后,GC 再次扫描发现该对象依然不可达,才会真正回收其内存。

    这意味着:Finalize 对象至少需要两次 GC 周期才能被回收,这严重拖慢了回收效率。

  2. 对象复活(Object Resurrection)

    这是 finalize 最诡异的特性。在 finalize() 方法中,你可以把 this 赋值给某个全局静态变量,从而让对象“死而复生”。

    • 单次执行保证:JVM 保证每个对象的 finalize() 只会被调用一次。如果对象复活后再次变成不可达,GC 将直接回收它,不再调用 finalize()

      java
      public class Zombie {
        public static Zombie INSTANCE;
        @Override
        protected void finalize() {
          System.out.println("Resurrecting...");
          INSTANCE = this; // 复活!重新建立强引用
        }
      }

注意事项

  1. 不确定性与性能灾难

    • 执行时机不确定:你无法保证 finalize 何时运行,甚至无法保证它一定会运行(例如 JVM 直接退出时)。因此,绝不能用它来释放关键资源(如数据库连接、文件句柄),否则极易导致资源耗尽(Resource Leak)。
    • 吞吐量下降:创建带有 finalize 的对象比较慢(需要注册),且回收需要至少两个 GC 周期。在大量创建此类对象的场景下,由于 Finalizer 线程优先级低,处理速度可能赶不上创建速度,导致 F-Queue 堆积,最终引发 OutOfMemoryError
  2. 异常被吞没

    如果 finalize() 方法中抛出未捕获的异常,该异常会被忽略,且不会打印任何堆栈信息。这使得调试变得极其困难,系统可能处于一种损坏的中间状态而无人知晓。

  3. 安全漏洞(Finalizer Attack)

    如果一个类的构造函数抛出异常(例如权限检查失败),对象本不应被创建。但如果有恶意子类覆盖了 finalize,该方法仍会被执行,攻击者可以在其中获取部分初始化的对象实例,绕过安全检查。

    • 防御:将类声明为 final,或在 finalize 中抛出异常(虽然没用),最佳方案是使用 finalfinalize 方法阻止重写:protected final void finalize() {}

扩展知识

  1. 替代方案(Best Practices)
    • AutoCloseable + try-with-resources(首选)

      显式管理资源生命周期,确定性强,代码清晰。

      java
      try (FileInputStream fis = new FileInputStream("file.txt")) {
        // 使用资源
      } // 自动调用 close()
    • java.lang.ref.Cleaner (Java 9+)

      finalize 的官方替代品。它利用虚引用(PhantomReference)机制,比 finalize 更轻量、更安全(无法复活对象),但依然不保证立即执行。通常用于兜底(Safety Net)。

补充

IDEA 中查看 JDK 源码

在 IntelliJ IDEA 中查看 JDK 源码(例如 String, ArrayList, Object 的底层实现)是 Java 进阶必经之路。IDEA 对此支持得非常完美。

以下是三种最常用的方法,以及如何解决“只能看反编译代码(.class)看不到源码(.java)”的问题

方法1:快捷键/鼠标跳转

方法一:快捷键/鼠标跳转(最常用):

这是日常开发中最快的方式。当你代码中写到了某个类(比如 String)或者某个方法(比如 System.out.println)时:

  1. 鼠标操作:Ctrl + 左击

    • 按住 Ctrl (Mac 是 Command) 键。

    • 鼠标移动到代码上的类名或方法名上(文字会变成超链接样式)。

    • 点击左键,直接跳转。

  2. 键盘操作:Ctrl + B

    • 光标停留在类名或方法名上。

    • 按下 Ctrl + B (Windows/Linux) 或 Command + B (Mac)。

方法2:全局搜索

方法二:全局搜索(想直接看某个类):

如果你当前代码里没有用到 HashMap,但你想专门去看看它的源码:

  1. 按下 Ctrl + N (Windows/Linux) 或 Command + O (Mac)。这是 "Go to Class"(查找类)功能。

    • 或者双击 Shift 打开 "Search Everywhere"。
  2. 输入类名,例如 HashMap

  3. 关键点: 确保勾选右上角的 "Include non-project items" (包含非项目文件) 或者再次按下快捷键,这样才能搜到 JDK 里的类。

  4. 回车进入。

方法3:从项目结构树查看

方法三:从项目结构树查看(浏览全貌):

如果你想浏览 JDK 到底包含了哪些包(Package):

  1. 打开左侧的 Project 面板。
  2. 拉到最下方,找到 External Libraries (外部库)。
  3. 展开 <1.8><11> (取决于你的 JDK 版本)。
  4. 展开 rt.jar (JDK 8) 或 java.base (JDK 9+)。
  5. 在这里你可以像翻文件夹一样浏览 java.lang, java.util 等包下的所有源码。

问题:为什么看的是 .class 文件

问题:为什么我看到的是 ".class" 文件:

如果你点进去后,看到 IDEA 顶部提示 "Decompiled .class file, bytecode version...",并且代码里没有注释,变量名可能是 var1, var2 这种奇怪的名字。

原因: IDEA 只找到了编译后的字节码,没有关联到 JDK 的源码压缩包 (src.zip)。

解决方法:

方案 A:点击提示栏的 "Download Sources":

IDEA 通常会在顶部弹出一个黄色条,点击 "Download Sources""Choose Sources...",它会自动尝试下载。

方案 B:手动关联(一劳永逸):

如果自动下载失败,说明你需要手动指定一下本地的源码包(通常安装 JDK 时都自带了)。

  1. 打开 File -> Project Structure (快捷键 Ctrl+Alt+Shift+S / Cmd+;)。
  2. 选择左侧的 SDKs (或 Platform Settings -> SDKs)。
  3. 选中你当前使用的 JDK 版本。
  4. 点击右侧的 Sourcepath 标签页。
  5. 点击 + 号,找到你 JDK 的安装目录。
  6. 选择目录下的 src.zip 文件(如果是 Linux 可能是 lib/src.zip)。
  7. 点击 OK 应用。

image-20260127180827163

神器技巧

Pro Tips:看源码的神器技巧:

  1. 查看大纲 (Structure): JDK 源码通常很长(String 有 3000 多行),直接看很累。

    • 按下 Alt + 7 (Windows) / Command + 7 (Mac) 打开 Structure 面板。
    • 这里列出了所有的方法和属性,可以直接点击跳转。
  2. 查看类图 (Diagram): 想搞清继承关系?

    • 在类名上右键 -> Diagrams -> Show Diagram
    • 它会画出这个类的父类、接口实现关系图。
  3. 看具体的实现类 (Implementation): 如果你点击 List 接口的方法,它只会带你到接口定义。你想看 ArrayList 是怎么实现的?

    • 使用 Ctrl + Alt + B (Windows) / Command + Option + B (Mac)。
    • 选择具体的实现类(如 ArrayList)。

您现在可以试着去 IDEA 里按住 Ctrl 点一下 String 类,看看它的 equals 方法是怎么写的(你会发现之前学的 instanceof 和数组比较都在里面)!